Coverage Report

Created: 2026-06-19 16:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\csshw\csshw\xtask\src\inject_agent_token.rs
Line
Count
Source
1
//! Paseo agent GitHub auth injection.
2
//!
3
//! A paseo-spawned agent would otherwise inherit the user's full `gh`
4
//! login - including classic scopes like `repo` that allow deleting
5
//! repositories or force-pushing to `main`. This module is the
6
//! counterpart of that risk: on worktree creation, it writes a
7
//! per-worktree `.claude/settings.local.json` whose `env` map carries
8
//! a fine-grained PAT supplied by the contributor. Claude Code
9
//! injects that `env` into the agent process, and `gh` honors
10
//! `GH_TOKEN` over the keyring, so the agent ends up acting as the
11
//! scoped PAT while the contributor's own `gh` session outside paseo
12
//! is unaffected.
13
//!
14
//! The token source is `<source-checkout>/.paseo/gh-token` - a
15
//! gitignored file the contributor creates once per clone. The
16
//! source checkout path is taken from the `PASEO_SOURCE_CHECKOUT_PATH`
17
//! environment variable paseo sets when running setup steps; if that
18
//! variable is absent, the current directory is used instead, which
19
//! covers manual `cargo xtask inject-agent-token` invocations from
20
//! the repo root.
21
//!
22
//! If the token file is missing the subcommand is a silent no-op
23
//! (with an informational log line). Fine-grained PATs
24
//! (`github_pat_...`) are recommended because they can be restricted
25
//! to specific repositories and to a subset of repository permissions.
26
//! Classic (`ghp_...`) and OAuth (`gho_...`) tokens are accepted to
27
//! avoid hard-blocking contributors who only have those, but each
28
//! triggers a warning log line since they cannot be scoped tightly
29
//! enough to preserve the least-privilege property. Any other content
30
//! is rejected so we never inject arbitrary text as a token.
31
32
use std::path::{Path, PathBuf};
33
34
use anyhow::{bail, Context, Result};
35
36
/// Prefix for a fine-grained personal access token. This is the
37
/// recommended token shape because it can be restricted to specific
38
/// repositories and to a subset of repository permissions.
39
const FINE_GRAINED_PREFIX: &str = "github_pat_";
40
41
/// Prefix for a classic personal access token. Accepted to avoid
42
/// hard-blocking contributors who only have a classic token, but
43
/// flagged with a warning since classic tokens cannot be scoped to
44
/// specific repositories or to a subset of repository permissions.
45
const CLASSIC_PREFIX: &str = "ghp_";
46
47
/// Prefix for an OAuth user-to-server token. Accepted with the same
48
/// caveat as [`CLASSIC_PREFIX`].
49
const OAUTH_PREFIX: &str = "gho_";
50
51
/// Relative path inside the source checkout where the contributor
52
/// stores their GitHub token.
53
const TOKEN_FILE_REL_PATH: &str = ".paseo/gh-token";
54
55
/// Relative path inside the worktree where Claude Code reads local,
56
/// uncommitted per-project settings.
57
const SETTINGS_FILE_REL_PATH: &str = ".claude/settings.local.json";
58
59
/// All side-effecting operations performed by this subcommand.
60
///
61
/// Implement with mocks in tests to achieve zero filesystem,
62
/// environment, or process side-effects.
63
pub trait InjectAgentTokenSystem {
64
    /// Look up an environment variable.
65
    ///
66
    /// # Arguments
67
    ///
68
    /// * `key` - Environment variable name.
69
    ///
70
    /// # Returns
71
    ///
72
    /// `Some(value)` when the variable is set and non-empty,
73
    /// `None` otherwise.
74
    fn env_var(&self, key: &str) -> Option<String>;
75
76
    /// Return the current working directory.
77
    ///
78
    /// # Errors
79
    ///
80
    /// Returns an error if the current directory cannot be
81
    /// determined.
82
    fn current_dir(&self) -> Result<PathBuf>;
83
84
    /// Read the token file at `path`.
85
    ///
86
    /// # Arguments
87
    ///
88
    /// * `path` - Absolute or worktree-relative path to the token
89
    ///   file.
90
    ///
91
    /// # Returns
92
    ///
93
    /// `Ok(Some(contents))` when the file exists and is readable,
94
    /// `Ok(None)` when it does not exist (the subcommand treats
95
    /// this as a no-op).
96
    ///
97
    /// # Errors
98
    ///
99
    /// Returns an error for filesystem failures other than
100
    /// "not found" (for example, permission denied).
101
    fn read_token_file(&self, path: &Path) -> Result<Option<String>>;
102
103
    /// Write `contents` to the settings file at `path`, creating
104
    /// any missing parent directories.
105
    ///
106
    /// # Arguments
107
    ///
108
    /// * `path` - Target path for the settings file.
109
    /// * `contents` - Full file contents to write.
110
    ///
111
    /// # Errors
112
    ///
113
    /// Returns an error if directory creation or the write fails.
114
    fn write_settings(&self, path: &Path, contents: &str) -> Result<()>;
115
116
    /// Emit an informational or warning message to the user.
117
    ///
118
    /// # Arguments
119
    ///
120
    /// * `msg` - Message to display.
121
    fn log(&self, msg: &str);
122
}
123
124
/// Production implementation of [`InjectAgentTokenSystem`].
125
pub struct RealSystem;
126
127
#[cfg_attr(coverage_nightly, coverage(off))]
128
impl InjectAgentTokenSystem for RealSystem {
129
    fn env_var(&self, key: &str) -> Option<String> {
130
        std::env::var(key).ok().filter(|v| !v.is_empty())
131
    }
132
133
    fn current_dir(&self) -> Result<PathBuf> {
134
        std::env::current_dir().context("failed to resolve current directory")
135
    }
136
137
    fn read_token_file(&self, path: &Path) -> Result<Option<String>> {
138
        match std::fs::read_to_string(path) {
139
            Ok(contents) => Ok(Some(contents)),
140
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
141
            Err(err) => Err(err).with_context(|| format!("failed to read {}", path.display())),
142
        }
143
    }
144
145
    fn write_settings(&self, path: &Path, contents: &str) -> Result<()> {
146
        if let Some(parent) = path.parent() {
147
            std::fs::create_dir_all(parent)
148
                .with_context(|| format!("failed to create {}", parent.display()))?;
149
        }
150
        std::fs::write(path, contents)
151
            .with_context(|| format!("failed to write {}", path.display()))?;
152
        Ok(())
153
    }
154
155
    fn log(&self, msg: &str) {
156
        println!("{msg}");
157
    }
158
}
159
160
/// Build the JSON body written to `.claude/settings.local.json`.
161
///
162
/// Caller-enforced invariant: `token` contains only bytes in
163
/// `[A-Za-z0-9_]`. That alphabet has no characters that require JSON
164
/// escaping, which is what lets this function skip a general-purpose
165
/// JSON encoder without risking injection. The invariant is enforced
166
/// by [`is_in_token_alphabet`] inside [`inject_agent_token`].
167
///
168
/// # Arguments
169
///
170
/// * `token` - GitHub token, already validated and trimmed.
171
///
172
/// # Returns
173
///
174
/// A pretty-printed JSON document terminated with a newline.
175
4
fn build_settings_body(token: &str) -> String {
176
4
    format!(
177
        "{{\n  \"env\": {{\n    \"GH_TOKEN\": \"{token}\",\n    \"GH_HOST\": \"github.com\"\n  }}\n}}\n"
178
    )
179
4
}
180
181
/// Return `true` when every byte of `token` is in the GitHub token
182
/// alphabet `[A-Za-z0-9_]`.
183
///
184
/// Enforcing this invariant is what lets [`build_settings_body`]
185
/// embed the token directly into a JSON template without escaping -
186
/// none of the characters in this alphabet need JSON escaping, so a
187
/// token that passes this check cannot break out of its string
188
/// literal nor inject additional keys. Fine-grained PATs, classic
189
/// PATs, and OAuth tokens all share the same alphabet, so the same
190
/// check applies to every accepted token shape.
191
///
192
/// # Arguments
193
///
194
/// * `token` - Trimmed token to validate.
195
///
196
/// # Returns
197
///
198
/// `true` when `token` is non-empty and contains only the allowed
199
/// characters; `false` otherwise.
200
5
fn is_in_token_alphabet(token: &str) -> bool {
201
5
    !token.is_empty()
202
5
        && token
203
5
            .bytes()
204
123
            .
all5
(|b| b.is_ascii_alphanumeric() ||
b == b'_'9
)
205
5
}
206
207
/// Recognized GitHub token shapes.
208
#[derive(Clone, Copy)]
209
enum TokenKind {
210
    FineGrained,
211
    Classic,
212
    OAuth,
213
}
214
215
impl TokenKind {
216
    /// Identify the token shape from its prefix.
217
    ///
218
    /// # Arguments
219
    ///
220
    /// * `token` - Trimmed token contents.
221
    ///
222
    /// # Returns
223
    ///
224
    /// `Some(kind)` when the token starts with a recognized prefix,
225
    /// `None` otherwise.
226
6
    fn classify(token: &str) -> Option<Self> {
227
6
        if token.starts_with(FINE_GRAINED_PREFIX) {
228
3
            Some(Self::FineGrained)
229
3
        } else if token.starts_with(CLASSIC_PREFIX) {
230
1
            Some(Self::Classic)
231
2
        } else if token.starts_with(OAUTH_PREFIX) {
232
1
            Some(Self::OAuth)
233
        } else {
234
1
            None
235
        }
236
6
    }
237
}
238
239
/// Resolve the source checkout directory.
240
///
241
/// Paseo passes `PASEO_SOURCE_CHECKOUT_PATH` into `worktree.setup`
242
/// subprocesses. When the variable is missing - for example when the
243
/// subcommand is invoked manually - fall back to the current
244
/// directory so running it from the repo root behaves intuitively.
245
///
246
/// # Arguments
247
///
248
/// * `system` - Injected I/O provider.
249
///
250
/// # Returns
251
///
252
/// The source checkout path.
253
///
254
/// # Errors
255
///
256
/// Returns an error only when the fallback `current_dir` lookup
257
/// fails.
258
9
fn resolve_source_checkout<S: InjectAgentTokenSystem>(system: &S) -> Result<PathBuf> {
259
9
    if let Some(
path8
) = system.env_var("PASEO_SOURCE_CHECKOUT_PATH") {
260
8
        return Ok(PathBuf::from(path));
261
1
    }
262
1
    system.current_dir()
263
9
}
264
265
/// Inject the contributor's GitHub token into the current worktree's
266
/// Claude Code settings.
267
///
268
/// The token is read from `<source-checkout>/.paseo/gh-token`. A
269
/// missing token file is treated as an opt-out: the function logs a
270
/// notice and returns `Ok(())` so worktree creation is not blocked
271
/// for contributors who have not set a token up yet. Fine-grained
272
/// PATs are written silently; classic and OAuth tokens are written
273
/// but trigger a warning log line recommending fine-grained PATs.
274
///
275
/// # Arguments
276
///
277
/// * `system` - Injected I/O provider.
278
///
279
/// # Returns
280
///
281
/// `Ok(())` on success or when the token file is absent.
282
///
283
/// # Errors
284
///
285
/// Returns an error when a token file exists but does not start with
286
/// one of the recognized prefixes ([`FINE_GRAINED_PREFIX`],
287
/// [`CLASSIC_PREFIX`], [`OAUTH_PREFIX`]), when its trimmed contents
288
/// fall outside the token alphabet (see [`is_in_token_alphabet`]),
289
/// or when the settings file cannot be written.
290
9
pub fn inject_agent_token<S: InjectAgentTokenSystem>(system: &S) -> Result<()> {
291
9
    let source = resolve_source_checkout(system)
?0
;
292
9
    let token_file = source.join(TOKEN_FILE_REL_PATH);
293
294
9
    let Some(
raw7
) = system.read_token_file(&token_file)
?0
else {
295
2
        system.log(&format!(
296
2
            "INFO - paseo agent GitHub auth: no {} found; agents will use your existing gh login. See CONTRIBUTING.md.",
297
2
            token_file.display()
298
2
        ));
299
2
        return Ok(());
300
    };
301
302
7
    let token = raw.trim();
303
7
    if token.is_empty() {
304
1
        bail!(
305
            "{} is empty; expected a GitHub token starting with `{}` (recommended), `{}`, or `{}`. See CONTRIBUTING.md.",
306
1
            token_file.display(),
307
            FINE_GRAINED_PREFIX,
308
            CLASSIC_PREFIX,
309
            OAUTH_PREFIX,
310
        );
311
6
    }
312
6
    let Some(
kind5
) = TokenKind::classify(token) else {
313
1
        bail!(
314
            "{} must contain a GitHub token starting with `{}` (recommended), `{}`, or `{}`. See CONTRIBUTING.md.",
315
1
            token_file.display(),
316
            FINE_GRAINED_PREFIX,
317
            CLASSIC_PREFIX,
318
            OAUTH_PREFIX,
319
        );
320
    };
321
5
    if !is_in_token_alphabet(token) {
322
1
        bail!(
323
            "{} contains characters outside the GitHub token alphabet ([A-Za-z0-9_]); refusing to embed it in settings. See CONTRIBUTING.md.",
324
1
            token_file.display()
325
        );
326
4
    }
327
328
4
    let cwd = system.current_dir()
?0
;
329
4
    let settings_path = cwd.join(SETTINGS_FILE_REL_PATH);
330
4
    let body = build_settings_body(token);
331
4
    system.write_settings(&settings_path, &body)
?0
;
332
333
4
    match kind {
334
2
        TokenKind::FineGrained => {
335
2
            system.log(&format!(
336
2
                "INFO - paseo agent GitHub auth: wrote {} from {} (scoped PAT)",
337
2
                settings_path.display(),
338
2
                token_file.display()
339
2
            ));
340
2
        }
341
1
        TokenKind::Classic => {
342
1
            system.log(&format!(
343
1
                "WARN - paseo agent GitHub auth: detected a classic token in {}; wrote {} but fine-grained PATs (prefix `{}`) are recommended because they can be restricted to specific repositories and permissions, while classic tokens cannot. See CONTRIBUTING.md.",
344
1
                token_file.display(),
345
1
                settings_path.display(),
346
1
                FINE_GRAINED_PREFIX,
347
1
            ));
348
1
        }
349
1
        TokenKind::OAuth => {
350
1
            system.log(&format!(
351
1
                "WARN - paseo agent GitHub auth: detected an OAuth token in {}; wrote {} but fine-grained PATs (prefix `{}`) are recommended because they can be restricted to specific repositories and permissions, while OAuth tokens cannot. See CONTRIBUTING.md.",
352
1
                token_file.display(),
353
1
                settings_path.display(),
354
1
                FINE_GRAINED_PREFIX,
355
1
            ));
356
1
        }
357
    }
358
359
4
    Ok(())
360
9
}
361
362
#[cfg(test)]
363
#[path = "tests/test_inject_agent_token.rs"]
364
mod tests;